From b38ab3eda896755a0174a24cad8f02fea6e0e422 Mon Sep 17 00:00:00 2001 From: apdn7 <106378158+apdn7@users.noreply.github.com> Date: Tue, 25 Apr 2023 14:26:15 +0900 Subject: [PATCH 1/3] 4.2.0 base files --- VERSION | 2 +- about/Endroll.md | 25 +- ap/__init__.py | 7 +- ap/aggregate_plot/__init__.py | 4 + ap/aggregate_plot/controllers.py | 19 + ap/aggregate_plot/services/__init__.py | 0 ap/aggregate_plot/services/utils.py | 0 ap/api/__init__.py | 2 + ap/api/aggregate_plot/__init__.py | 0 ap/api/aggregate_plot/controllers.py | 96 ++ ap/api/aggregate_plot/services.py | 375 +++++++ ap/api/analyze/services/pca.py | 6 +- ap/api/heatmap/services.py | 21 +- .../sankey_glasso/sankey_services.py | 16 +- ap/api/scatter_plot/services.py | 37 +- ap/api/setting_module/services/csv_import.py | 4 +- .../services/show_latest_record.py | 42 +- ap/api/trace_data/services/csv_export.py | 5 +- ap/api/trace_data/services/proc_link.py | 3 +- .../trace_data/services/time_series_chart.py | 13 +- ap/common/assets/assets.json | 216 ++++ ap/common/common_utils.py | 43 +- ap/common/constants.py | 17 +- ap/common/services/ana_inf_data.py | 41 +- ap/common/services/csv_content.py | 13 +- ap/common/services/form_env.py | 21 +- ap/common/trace_data_log.py | 1 + ap/config/image/AgP.png | Bin 0 -> 39352 bytes .../tile_interface_analysis_platform.yml | 27 +- ap/setting_module/models.py | 14 +- .../aggregate_plot/css/aggregate_plot.css | 79 ++ ap/static/aggregate_plot/js/aggregate_plot.js | 528 ++++++++++ .../aggregate_plot/js/aggregation_chart.js | 138 +++ ap/static/analyze/js/hotelling_common.js | 72 +- .../analyze/js/hotelling_q_contribution.js | 6 +- ap/static/analyze/js/pca.js | 5 +- .../categorical_plot/js/categorical_plot.js | 10 +- ap/static/common/css/main.css | 10 +- ap/static/common/css/user-setting-table.css | 4 +- ap/static/common/icons/AgP.ico | Bin 0 -> 41203 bytes ap/static/common/js/base.js | 108 +- ap/static/common/js/components.js | 180 ++-- ap/static/common/js/data-finder.js | 25 +- ap/static/common/js/divide_by_calendar.js | 248 +++++ ap/static/common/js/save_load_user_input.js | 11 +- ap/static/common/js/utils.js | 122 ++- ap/static/heatmap/css/heat_map.css | 21 +- ap/static/heatmap/js/heatmap.js | 51 +- .../daterangepicker-utils.js | 3 +- .../js/multiple_scatter_plot.js | 5 +- ap/static/parallel_plot/js/parallel_plot.js | 20 +- ap/static/ridgeline_plot/js/ridgeline_plot.js | 16 +- ap/static/sankey_plot/js/sankey_plot.js | 21 +- ap/static/scatter_plot/css/scatter_plot.css | 3 +- ap/static/scatter_plot/js/scatter_chart.js | 4 +- ap/static/scatter_plot/js/scatter_plot.js | 54 +- ap/static/trace_data/js/trace_data.js | 22 +- .../aggregate_plot/aggregate_plot.html | 78 ++ ap/templates/analyze/hotelling_tsquare.html | 33 +- ap/templates/base.html | 98 +- .../categorical_plot/categorical_plot.html | 28 +- .../co_occurrence/co_occurrence_csv.html | 20 +- ap/templates/graph_nav.html | 5 - ap/templates/header.html | 2 +- ap/templates/heatmap/heatmap.html | 27 +- ap/templates/i18n.html | 1 + ap/templates/macros.html | 91 +- ap/templates/modal.html | 2 +- .../multiple_scatter_plot.html | 30 +- ap/templates/parallel_plot/parallel_plot.html | 27 +- .../ridgeline_plot/ridgeline_plot.html | 32 +- ap/templates/sankey_plot/sankey_plot.html | 39 +- ap/templates/scatter_plot/scatter_plot.html | 54 +- ap/templates/sidebar.html | 7 + ap/templates/trace_data/trace_data.html | 44 +- ap/trace_data/schemas.py | 63 +- ap/translations/ar/LC_MESSAGES/messages.mo | Bin 92568 -> 93860 bytes ap/translations/ar/LC_MESSAGES/messages.po | 93 +- ap/translations/bg/LC_MESSAGES/messages.mo | Bin 105729 -> 107021 bytes ap/translations/bg/LC_MESSAGES/messages.po | 93 +- ap/translations/ca/LC_MESSAGES/messages.mo | Bin 82563 -> 83814 bytes ap/translations/ca/LC_MESSAGES/messages.po | 93 +- ap/translations/cs/LC_MESSAGES/messages.mo | Bin 79708 -> 80971 bytes ap/translations/cs/LC_MESSAGES/messages.po | 93 +- ap/translations/cy/LC_MESSAGES/messages.mo | Bin 78295 -> 79550 bytes ap/translations/cy/LC_MESSAGES/messages.po | 93 +- ap/translations/da/LC_MESSAGES/messages.mo | Bin 78027 -> 79268 bytes ap/translations/da/LC_MESSAGES/messages.po | 93 +- ap/translations/de/LC_MESSAGES/messages.mo | Bin 82801 -> 84049 bytes ap/translations/de/LC_MESSAGES/messages.po | 93 +- ap/translations/el/LC_MESSAGES/messages.mo | Bin 109878 -> 111171 bytes ap/translations/el/LC_MESSAGES/messages.po | 94 +- ap/translations/en/LC_MESSAGES/messages.mo | Bin 76956 -> 78246 bytes ap/translations/en/LC_MESSAGES/messages.po | 61 ++ ap/translations/es/LC_MESSAGES/messages.mo | Bin 82827 -> 84085 bytes ap/translations/es/LC_MESSAGES/messages.po | 94 +- ap/translations/fa/LC_MESSAGES/messages.mo | Bin 95495 -> 96765 bytes ap/translations/fa/LC_MESSAGES/messages.po | 94 +- ap/translations/fi/LC_MESSAGES/messages.mo | Bin 78533 -> 79781 bytes ap/translations/fi/LC_MESSAGES/messages.po | 94 +- ap/translations/fr/LC_MESSAGES/messages.mo | Bin 84787 -> 86043 bytes ap/translations/fr/LC_MESSAGES/messages.po | 94 +- ap/translations/gd/LC_MESSAGES/messages.mo | Bin 85395 -> 86664 bytes ap/translations/gd/LC_MESSAGES/messages.po | 93 +- ap/translations/he/LC_MESSAGES/messages.mo | Bin 88429 -> 89700 bytes ap/translations/he/LC_MESSAGES/messages.po | 94 +- ap/translations/hi/LC_MESSAGES/messages.mo | Bin 119956 -> 121299 bytes ap/translations/hi/LC_MESSAGES/messages.po | 93 +- ap/translations/hr/LC_MESSAGES/messages.mo | Bin 78617 -> 79866 bytes ap/translations/hr/LC_MESSAGES/messages.po | 93 +- ap/translations/hu/LC_MESSAGES/messages.mo | Bin 82752 -> 84011 bytes ap/translations/hu/LC_MESSAGES/messages.po | 94 +- ap/translations/id/LC_MESSAGES/messages.mo | Bin 78196 -> 79452 bytes ap/translations/id/LC_MESSAGES/messages.po | 94 +- ap/translations/is/LC_MESSAGES/messages.mo | Bin 78920 -> 80170 bytes ap/translations/is/LC_MESSAGES/messages.po | 94 +- ap/translations/it/LC_MESSAGES/messages.mo | Bin 82123 -> 83374 bytes ap/translations/it/LC_MESSAGES/messages.po | 93 +- ap/translations/ja/LC_MESSAGES/messages.mo | Bin 86304 -> 87644 bytes ap/translations/ja/LC_MESSAGES/messages.po | 51 + ap/translations/jv/LC_MESSAGES/messages.mo | Bin 77180 -> 78437 bytes ap/translations/jv/LC_MESSAGES/messages.po | 94 +- ap/translations/km/LC_MESSAGES/messages.mo | Bin 124089 -> 125440 bytes ap/translations/km/LC_MESSAGES/messages.po | 94 +- ap/translations/ko/LC_MESSAGES/messages.mo | Bin 82039 -> 83283 bytes ap/translations/ko/LC_MESSAGES/messages.po | 94 +- ap/translations/lb/LC_MESSAGES/messages.mo | Bin 80865 -> 82111 bytes ap/translations/lb/LC_MESSAGES/messages.po | 93 +- ap/translations/mi/LC_MESSAGES/messages.mo | Bin 80985 -> 82235 bytes ap/translations/mi/LC_MESSAGES/messages.po | 94 +- ap/translations/mk/LC_MESSAGES/messages.mo | Bin 107493 -> 108797 bytes ap/translations/mk/LC_MESSAGES/messages.po | 94 +- ap/translations/mn/LC_MESSAGES/messages.mo | Bin 101979 -> 103264 bytes ap/translations/mn/LC_MESSAGES/messages.po | 94 +- ap/translations/ms/LC_MESSAGES/messages.mo | Bin 78725 -> 79978 bytes ap/translations/ms/LC_MESSAGES/messages.po | 94 +- ap/translations/my/LC_MESSAGES/messages.mo | Bin 129622 -> 130957 bytes ap/translations/my/LC_MESSAGES/messages.po | 94 +- ap/translations/ne/LC_MESSAGES/messages.mo | Bin 120895 -> 122261 bytes ap/translations/ne/LC_MESSAGES/messages.po | 94 +- ap/translations/nl/LC_MESSAGES/messages.mo | Bin 80768 -> 82011 bytes ap/translations/nl/LC_MESSAGES/messages.po | 94 +- ap/translations/no/LC_MESSAGES/messages.mo | Bin 77573 -> 78816 bytes ap/translations/no/LC_MESSAGES/messages.po | 94 +- ap/translations/pa/LC_MESSAGES/messages.mo | Bin 116648 -> 117968 bytes ap/translations/pa/LC_MESSAGES/messages.po | 94 +- ap/translations/pl/LC_MESSAGES/messages.mo | Bin 80799 -> 82052 bytes ap/translations/pl/LC_MESSAGES/messages.po | 94 +- ap/translations/pt/LC_MESSAGES/messages.mo | Bin 82180 -> 83431 bytes ap/translations/pt/LC_MESSAGES/messages.po | 94 +- ap/translations/ro/LC_MESSAGES/messages.mo | Bin 82241 -> 83500 bytes ap/translations/ro/LC_MESSAGES/messages.po | 94 +- ap/translations/ru/LC_MESSAGES/messages.mo | Bin 105373 -> 106696 bytes ap/translations/ru/LC_MESSAGES/messages.po | 94 +- ap/translations/sd/LC_MESSAGES/messages.mo | Bin 92338 -> 93627 bytes ap/translations/sd/LC_MESSAGES/messages.po | 94 +- ap/translations/si/LC_MESSAGES/messages.mo | Bin 118724 -> 120056 bytes ap/translations/si/LC_MESSAGES/messages.po | 94 +- ap/translations/sk/LC_MESSAGES/messages.mo | Bin 80172 -> 81446 bytes ap/translations/sk/LC_MESSAGES/messages.po | 94 +- ap/translations/sq/LC_MESSAGES/messages.mo | Bin 83805 -> 85055 bytes ap/translations/sq/LC_MESSAGES/messages.po | 94 +- ap/translations/sv/LC_MESSAGES/messages.mo | Bin 78252 -> 79635 bytes ap/translations/sv/LC_MESSAGES/messages.po | 97 +- ap/translations/te/LC_MESSAGES/messages.mo | Bin 125600 -> 127072 bytes ap/translations/te/LC_MESSAGES/messages.po | 97 +- ap/translations/th/LC_MESSAGES/messages.mo | Bin 117999 -> 119311 bytes ap/translations/th/LC_MESSAGES/messages.po | 94 +- ap/translations/tl/LC_MESSAGES/messages.mo | Bin 83334 -> 84738 bytes ap/translations/tl/LC_MESSAGES/messages.po | 97 +- ap/translations/tr/LC_MESSAGES/messages.mo | Bin 79664 -> 81053 bytes ap/translations/tr/LC_MESSAGES/messages.po | 97 +- ap/translations/vi/LC_MESSAGES/messages.mo | Bin 88837 -> 90134 bytes ap/translations/vi/LC_MESSAGES/messages.po | 79 +- .../zh_Hans_CN/LC_MESSAGES/messages.mo | Bin 73075 -> 74463 bytes .../zh_Hans_CN/LC_MESSAGES/messages.po | 97 +- .../zh_Hant_TW/LC_MESSAGES/messages.mo | Bin 73069 -> 74457 bytes .../zh_Hant_TW/LC_MESSAGES/messages.po | 97 +- init/app.sqlite3 | Bin 401408 -> 401408 bytes lang/message.pot | 57 +- main.py | 6 +- requirements/common.txt | 2 +- sample_data/AgP_sample_data/AgP_sample.tsv | 933 ++++++++++++++++++ 183 files changed, 6967 insertions(+), 2257 deletions(-) create mode 100644 ap/aggregate_plot/__init__.py create mode 100644 ap/aggregate_plot/controllers.py create mode 100644 ap/aggregate_plot/services/__init__.py create mode 100644 ap/aggregate_plot/services/utils.py create mode 100644 ap/api/aggregate_plot/__init__.py create mode 100644 ap/api/aggregate_plot/controllers.py create mode 100644 ap/api/aggregate_plot/services.py create mode 100644 ap/common/assets/assets.json create mode 100644 ap/config/image/AgP.png create mode 100644 ap/static/aggregate_plot/css/aggregate_plot.css create mode 100644 ap/static/aggregate_plot/js/aggregate_plot.js create mode 100644 ap/static/aggregate_plot/js/aggregation_chart.js create mode 100644 ap/static/common/icons/AgP.ico create mode 100644 ap/static/common/js/divide_by_calendar.js create mode 100644 ap/templates/aggregate_plot/aggregate_plot.html create mode 100644 sample_data/AgP_sample_data/AgP_sample.tsv diff --git a/VERSION b/VERSION index 2cb6ac2..660a0d2 100644 --- a/VERSION +++ b/VERSION @@ -1,4 +1,4 @@ -v4.1.2.178.79921487 +v4.2.0.181.3f08083a 1 OSS diff --git a/about/Endroll.md b/about/Endroll.md index 6cf3528..166a285 100644 --- a/about/Endroll.md +++ b/about/Endroll.md @@ -58,9 +58,9 @@ - [Early Bird Collaborators](#early-bird-collaborators) - [Research & Development Team](#research--development-team) - [Data Analysis Education, Research & Promotion](#data-analysis-education-research--promotion) - - [Data Analysis Package](#data-analysis-package) - - [Data Analysis Interface](#data-analysis-interface) - - [Data Analysis Platform](#data-analysis-platform) + - [Analysis Package](#analysis-package) + - [Analysis Interface](#analysis-interface) + - [Analysis Platform](#analysis-platform) - [© F-IoT DAC Team & Rainbow7 + Bridge7](#-f-iot-dac-team--rainbow7--bridge7) @@ -142,15 +142,15 @@ |Data Analysis Education Development & Management||Takero Arakawa 荒川 毅郎 DNJP Monozukuri DX Promotion Div.|
-## Data Analysis Package +## Analysis Package |||| |--:|:-:|:--| -|Data Analysis Package Development & Management Leader||Genta Kikuchi 菊池 元太 DNJP Monozukuri DX Promotion Div.| -|Data Analysis Package Development||Sho Takahashi 髙橋 翔 DNJP Monozukuri DX Promotion Div.| +|Analysis Package Development & Management Leader||Genta Kikuchi 菊池 元太 DNJP Monozukuri DX Promotion Div.| +|Analysis Package Development||Sho Takahashi 髙橋 翔 DNJP Monozukuri DX Promotion Div.|
-## Data Analysis Interface +## Analysis Interface |||| |--:|:-:|:--| @@ -159,21 +159,22 @@ |||Pham Minh Hoang ファム ミン ホアン Phạm Minh Hoàng FPT Software Japan| |||Tran Thi Kim Tuyen チャン ティ キム トゥエン Trần Thị Kim Tuyền FPT Software Japan| |||Nguyen Huu Tuan グエン フー トゥアン Nguyễn Hữu Tuấn FPT Software Japan| +|||Duong Quoc Khanh ズオン クォック カイン Dương Quốc Khánh FPT Software Japan| |||Nguyen Van Hoai グエン ヴァン ホアイ Nguyễn Văn Hoài| |Technology Leader of Rainbow7||Masato Yasuda 安田 真人 DNJP Monozukuri DX Promotion Div.| |Agile Master of Rainbow7 & Bridge7||Yasutomo Kawashima 川島 恭朋 DNJP Monozukuri DX Promotion Div.|
-## Data Analysis Platform +## Analysis Platform |||| |--:|:-:|:--| -|Data Analysis Platform Product Owner FY20-||Tatsunori Kojo 古城 達則 DNJP Monozukuri DX Promotion Div.| -|Technology Leader of Data Analysis & Data Analysis Platform Product Owner FY19||Genta Kikuchi 菊池 元太 DNJP Monozukuri DX Promotion Div.| -|Data Analysis Platform Development||Takero Arakawa 荒川 毅郎 DNJP Monozukuri DX Promotion Div.| +|Analysis Platform Product Owner FY20-||Tatsunori Kojo 古城 達則 DNJP Monozukuri DX Promotion Div.| +|Technology Leader of Data Analysis & Analysis Platform Product Owner FY19||Genta Kikuchi 菊池 元太 DNJP Monozukuri DX Promotion Div.| +|Analysis Platform Development||Takero Arakawa 荒川 毅郎 DNJP Monozukuri DX Promotion Div.| |||Sho Takahashi 髙橋 翔 DNJP Monozukuri DX Promotion Div.| |Supervisor & Technology Leader of Data Analysis & SQC||Mutsumi Yoshino 吉野 睦 DNJP Monozukuri DX Promotion Div.| -|Supervisor & Senior Manager||Toshikuni Shinohara 篠原 壽邦 DNJP Monozukuri DX Promotion Div.| +|Supervisor & Senior Manager||Toshikuni Shinohara 篠原 壽邦|


diff --git a/ap/__init__.py b/ap/__init__.py index 6e5bb34..f08ba67 100644 --- a/ap/__init__.py +++ b/ap/__init__.py @@ -21,7 +21,7 @@ from ap.common.constants import FlaskGKey, SQLITE_CONFIG_DIR, PARTITION_NUMBER, UNIVERSAL_DB_FILE, APP_DB_FILE, \ TESTING, YAML_CONFIG_VERSION, YAML_CONFIG_BASIC, YAML_CONFIG_DB, YAML_CONFIG_PROC, YAML_CONFIG_AP, \ INIT_APP_DB_FILE, INIT_BASIC_CFG_FILE, REQUEST_THREAD_ID, YAML_START_UP, LOG_LEVEL, AP_LOG_LEVEL, AppGroup, \ - AppSource + AppSource, appENV from ap.common.logger import log_execution from ap.common.services.request_time_out_handler import RequestTimeOutAPI, set_request_g_dict from ap.common.trace_data_log import get_log_attr, TraceErrKey @@ -118,6 +118,7 @@ def create_app(object_name=None): from .sankey_plot import create_module as sankey_create_module from .co_occurrence import create_module as co_occurrence_create_module from .multiple_scatter_plot import create_module as multiple_scatter_create_module + from .aggregate_plot import create_module as agp_create_module from .common.logger import bind_user_info from .script.convert_user_setting import convert_user_setting_url from .script.migrate_csv_datatype import migrate_csv_datatype @@ -202,6 +203,7 @@ def create_app(object_name=None): co_occurrence_create_module(app) multiple_scatter_create_module(app) tile_interface_create_module(app) + agp_create_module(app) app.add_url_rule('/', endpoint='tile_interface.tile_interface') basic_config_yaml = BasicConfigYaml(dic_yaml_config_file[YAML_CONFIG_BASIC]) @@ -272,6 +274,8 @@ def before_request_callback(): # get the last time user request global dic_request_info + + # get api request thread id thread_id = request.form.get(REQUEST_THREAD_ID, None) set_request_g_dict(thread_id) @@ -294,6 +298,7 @@ def before_request_callback(): "url": "https://www.google.com/chrome/" }) + @app.after_request def after_request_callback(response: Response): if 'event-stream' in str(request.accept_mimetypes): diff --git a/ap/aggregate_plot/__init__.py b/ap/aggregate_plot/__init__.py new file mode 100644 index 0000000..203ed71 --- /dev/null +++ b/ap/aggregate_plot/__init__.py @@ -0,0 +1,4 @@ + +def create_module(app, **kwargs): + from .controllers import agp_blueprint + app.register_blueprint(agp_blueprint) diff --git a/ap/aggregate_plot/controllers.py b/ap/aggregate_plot/controllers.py new file mode 100644 index 0000000..b2a74f2 --- /dev/null +++ b/ap/aggregate_plot/controllers.py @@ -0,0 +1,19 @@ +import os + +from flask import Blueprint, render_template + +from ap.common.services.form_env import get_common_config_data + +agp_blueprint = Blueprint( + 'agp', + __name__, + template_folder=os.path.join('..', 'templates', 'aggregate_plot'), + static_folder=os.path.join('..', 'static', 'aggregate_plot'), + url_prefix='/ap' +) + + +@agp_blueprint.route('/agp') +def index(): + output_dict = get_common_config_data() + return render_template("aggregate_plot.html", **output_dict) diff --git a/ap/aggregate_plot/services/__init__.py b/ap/aggregate_plot/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ap/aggregate_plot/services/utils.py b/ap/aggregate_plot/services/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/ap/api/__init__.py b/ap/api/__init__.py index 499c753..bb415dd 100644 --- a/ap/api/__init__.py +++ b/ap/api/__init__.py @@ -12,6 +12,7 @@ def create_module(app, **kwargs): from .heatmap.controllers import api_heatmap_blueprint from .parallel_plot.controllers import api_paracords_blueprint from .common.controlllers import api_common_blueprint + from .aggregate_plot.controllers import api_agp_blueprint app.register_blueprint(api_setting_module_blueprint) app.register_blueprint(api_trace_data_blueprint) app.register_blueprint(api_table_viewer_blueprint) @@ -25,3 +26,4 @@ def create_module(app, **kwargs): app.register_blueprint(api_heatmap_blueprint) app.register_blueprint(api_paracords_blueprint) app.register_blueprint(api_common_blueprint) + app.register_blueprint(api_agp_blueprint) diff --git a/ap/api/aggregate_plot/__init__.py b/ap/api/aggregate_plot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ap/api/aggregate_plot/controllers.py b/ap/api/aggregate_plot/controllers.py new file mode 100644 index 0000000..6e23038 --- /dev/null +++ b/ap/api/aggregate_plot/controllers.py @@ -0,0 +1,96 @@ +import timeit +from copy import deepcopy + +import pytz +import simplejson +from dateutil import tz +from flask import Blueprint, request + +from ap.api.aggregate_plot.services import gen_agp_data +from ap.api.categorical_plot.services import customize_dict_param +from ap.api.trace_data.services.csv_export import to_csv +from ap.common.constants import COMMON, ARRAY_FORMVAL, END_PROC, CLIENT_TIMEZONE +from ap.common.services import http_content +from ap.common.services.csv_content import zip_file_to_response +from ap.common.services.form_env import parse_multi_filter_into_one, get_end_procs_param, \ + update_data_from_multiple_dic_params, parse_request_params +from ap.common.services.import_export_config_n_data import get_dic_form_from_debug_info, \ + set_export_dataset_id_to_dic_param +from ap.common.trace_data_log import save_input_data_to_file, EventType, save_draw_graph_trace, trace_log_params + +api_agp_blueprint = Blueprint( + 'api_agp', + __name__, + url_prefix='/ap/api/agp' +) + + +@api_agp_blueprint.route('/plot', methods=['POST']) +def generate_agp(): + """ [summary] + Returns: + [type] -- [description] + """ + start = timeit.default_timer() + dic_form = request.form.to_dict(flat=False) + # save dic_form to pickle (for future debug) + save_input_data_to_file(dic_form, EventType.AGP) + + dic_param = parse_multi_filter_into_one(dic_form) + + # check if we run debug mode (import mode) + dic_param = get_dic_form_from_debug_info(dic_param) + + customize_dict_param(dic_param) + org_dic_param = deepcopy(dic_param) + dic_params = get_end_procs_param(dic_param) + + for single_dic_param in dic_params: + agp_data, *_ = gen_agp_data(single_dic_param) + org_dic_param = update_data_from_multiple_dic_params(org_dic_param, agp_data) + + stop = timeit.default_timer() + org_dic_param['backend_time'] = stop - start + + # export mode ( output for export mode ) + set_export_dataset_id_to_dic_param(org_dic_param) + + org_dic_param['dataset_id'] = save_draw_graph_trace(vals=trace_log_params(EventType.CHM)) + + return simplejson.dumps(org_dic_param, ensure_ascii=False, default=http_content.json_serial, ignore_nan=True) + + +@api_agp_blueprint.route('/data_export/', methods=['GET']) +def data_export(export_type): + """csv export + + Returns: + [type] -- [description] + """ + dic_form = parse_request_params(request) + dic_param = parse_multi_filter_into_one(dic_form) + + # check if we run debug mode (import mode) + dic_param = get_dic_form_from_debug_info(dic_param) + + customize_dict_param(dic_param) + dic_params = get_end_procs_param(dic_param) + + delimiter = ',' if export_type == 'csv' else '\t' + + agp_dataset = [] + csv_list_name = [] + for single_dic_param in dic_params: + agp_dat, agp_df, graph_param, dic_proc_cfgs = gen_agp_data(single_dic_param) + end_proc_id = int(agp_dat[ARRAY_FORMVAL][0][END_PROC]) + proc_name = dic_proc_cfgs[end_proc_id].name + csv_list_name.append('{}.{}'.format(proc_name, export_type)) + + client_timezone = agp_dat[COMMON].get(CLIENT_TIMEZONE) + client_timezone = pytz.timezone(client_timezone) if client_timezone else tz.tzlocal() + csv_df = to_csv(agp_df, dic_proc_cfgs, graph_param, client_timezone=client_timezone, delimiter=delimiter) + agp_dataset.append(csv_df) + + response = zip_file_to_response(agp_dataset, csv_list_name) + response.charset = "utf-8-sig" + return response diff --git a/ap/api/aggregate_plot/services.py b/ap/api/aggregate_plot/services.py new file mode 100644 index 0000000..be6d7c9 --- /dev/null +++ b/ap/api/aggregate_plot/services.py @@ -0,0 +1,375 @@ +import copy +from datetime import timedelta +from typing import List, Optional, Dict, Any, Tuple + +import pandas as pd +from dateutil.tz import tz +from pandas.core.groupby import DataFrameGroupBy +from scipy.stats import iqr + +from ap.api.categorical_plot.services import gen_graph_param +from ap.api.heatmap.services import agg_func_with_na +from ap.api.heatmap.services import get_function_i18n, range_func +from ap.api.trace_data.services.time_series_chart import customize_dic_param_for_reuse_cache, get_data_from_db, \ + filter_cat_dict_common +from ap.common.common_utils import gen_sql_label +from ap.common.constants import ARRAY_PLOTDATA, HMFunction, ACTUAL_RECORD_NUMBER, UNIQUE_SERIAL, DIVIDE_FMT_COL, \ + COLOR_NAME, AGG_FUNC, DATA, COL_DATA_TYPE, DataType, CAT_EXP_BOX, COL_DETAIL_NAME, COL_TYPE, UNIQUE_DIV, FMT, \ + END_PROC_ID, END_COL_NAME +from ap.common.logger import log_execution_time +from ap.common.memoize import memoize +from ap.common.services.request_time_out_handler import abort_process_handler, request_timeout_handling +from ap.common.services.sse import notify_progress +from ap.common.sigificant_digit import get_fmt_from_array +from ap.common.timezone_utils import get_utc_offset +from ap.common.trace_data_log import TraceErrKey, EventType, EventAction, Target, trace_log +from ap.setting_module.models import CfgProcessColumn +from ap.trace_data.models import Cycle +from ap.trace_data.schemas import DicParam + +CHM_AGG_FUNC = [HMFunction.median.name, HMFunction.mean.name, HMFunction.std.name] + +# TODO: rename and move this to constant +OTHER_KEY = 'Other' +OTHER_COL = 'summarized_other_col' +MAX_ALLOW_GROUPS = 9 + + +@log_execution_time() +@request_timeout_handling() +@abort_process_handler() +@notify_progress(75) +@trace_log((TraceErrKey.TYPE, TraceErrKey.ACTION, TraceErrKey.TARGET), + (EventType.AGP, EventAction.PLOT, Target.GRAPH), send_ga=True) +@memoize(is_save_file=True) +def gen_agp_data(dic_param: DicParam): + dic_param, cat_exp, cat_procs, dic_cat_filters, use_expired_cache, *_ = \ + customize_dic_param_for_reuse_cache(dic_param) + + # gen graph_param + graph_param, dic_proc_cfgs = gen_graph_param(dic_param, with_ct_col=True) + graph_param.add_agp_color_vars() + + df, actual_number_records, duplicated_serials = get_data_from_db(graph_param, dic_cat_filters, + use_expired_cache=use_expired_cache) + + df, dic_param = filter_cat_dict_common(df, dic_param, dic_cat_filters, cat_exp, cat_procs, graph_param) + df = convert_utc_to_local_time_and_offset(df, graph_param) + + graph_param: DicParam + if graph_param.common.divide_format is not None: + df = gen_divide_format_column(df, graph_param.common.divide_calendar_dates, + graph_param.common.divide_calendar_labels) + + export_data = df + + dic_data = gen_agp_data_from_df(df, graph_param) + dic_param[ARRAY_PLOTDATA] = dic_data + dic_param[ACTUAL_RECORD_NUMBER] = actual_number_records + dic_param[UNIQUE_SERIAL] = duplicated_serials + + return dic_param, export_data, graph_param, dic_proc_cfgs + + +@log_execution_time() +def gen_divide_format_column(df: pd.DataFrame, divide_calendar_dates: List[str], + divide_calendar_labels: List[str]) -> pd.DataFrame: + df.sort_values(Cycle.time.key, inplace=True) + dt = df[Cycle.time.key] + df[DIVIDE_FMT_COL] = None + divide_calendar_dates = pd.to_datetime(divide_calendar_dates, utc=True) + for i, label in enumerate(divide_calendar_labels): + start_time = divide_calendar_dates[i] + end_time = divide_calendar_dates[i + 1] + start_index, end_index = dt.searchsorted([start_time, end_time]) + df[start_index:end_index][DIVIDE_FMT_COL] = label + return df + + +@log_execution_time() +def gen_agp_data_from_df(df: pd.DataFrame, graph_param: DicParam) -> List[Dict[Any, Any]]: + plot_data = [] + target_vars = graph_param.common.sensor_cols + + # calculate cycle_time and replace target column + for target_var in target_vars: + # find proc_id from col info + general_col_info = graph_param.get_col_info_by_id(target_var) + # list out all CT column from this process + dic_datetime_cols = {cfg_col.id: cfg_col for cfg_col in + CfgProcessColumn.get_by_data_type(general_col_info[END_PROC_ID], DataType.DATETIME)} + sql_label = gen_sql_label(target_var, general_col_info[END_COL_NAME]) + if target_var in dic_datetime_cols: + series = pd.to_datetime(df[sql_label]) + series.sort_values(inplace=True) + series = series.diff().dt.total_seconds() + series.sort_index(inplace=True) + df.sort_index(inplace=True) + df[sql_label] = series + df[sql_label].convert_dtypes() + + # each target var be shown on one chart (barchart or line chart) + for target_var in target_vars: + general_col_info = graph_param.get_col_info_by_id(target_var) + is_real_data = general_col_info[COL_DATA_TYPE] in [DataType.REAL.name, DataType.DATETIME.name] + agg_func = graph_param.common.hm_function_real if is_real_data else HMFunction.count.name + agg_func_show = get_function_i18n(agg_func) + + summarized_df, sorted_colors = summarize_redundant_groups_into_others(df, graph_param, target_var, + MAX_ALLOW_GROUPS, OTHER_KEY, OTHER_COL) + df_groupby = gen_groupby_from_target_var(summarized_df, graph_param, target_var, is_real_data) + + # get unique sorted div + unique_div_vars = [] + div_col_name = get_div_col_name(graph_param) + if div_col_name != DIVIDE_FMT_COL: + unique_div_vars = sorted(df[div_col_name].dropna().unique()) + + if general_col_info is None: + continue + + color_show_name = graph_param.get_color_info(target_var, shown_name=True) + + agp_obj = { + COLOR_NAME: color_show_name, + AGG_FUNC: agg_func_show, + DATA: [], + CAT_EXP_BOX: [], + UNIQUE_DIV: unique_div_vars, + FMT: None, + } + general_col_info.update(agp_obj) + + if is_real_data: + target_var_label = CfgProcessColumn.gen_label_from_col_id(target_var) + agg_df = get_agg_lamda_func(df_groupby, target_var_label, agg_func) + else: + agg_df = df_groupby.count() + + num_facets = len(graph_param.common.cat_exp) + + # TODO refactor this + if num_facets == 0: + data = get_data_for_target_var_without_facets(agg_df, graph_param, is_real_data, target_var, sorted_colors) + modified_agp_obj = {DATA: data, CAT_EXP_BOX: [], FMT: gen_ticks_format(data)} + col_info = copy.deepcopy(general_col_info) + col_info.update(modified_agp_obj) + plot_data.append(col_info) + + elif num_facets == 1: + + # TODO: remove hard code level=0 + facets = agg_df.index.unique(level=0) + for facet in facets: + data = get_data_for_target_var_without_facets(agg_df.xs(facet), graph_param, is_real_data, + target_var, sorted_colors) + modified_agp_obj = { + DATA: data, + CAT_EXP_BOX: [facet], + FMT: gen_ticks_format(data) + } + col_info = copy.deepcopy(general_col_info) + col_info.update(modified_agp_obj) + plot_data.append(col_info) + + else: + # TODO: remove hard code level=0, level=1 + facets1 = agg_df.index.unique(level=0) + facets2 = agg_df.index.unique(level=1) + for facet1 in facets1: + sub_df = agg_df.xs(facet1) + for facet2 in facets2: + try: + sub_sub_df = sub_df.xs(facet2) + data = get_data_for_target_var_without_facets(sub_sub_df, graph_param, is_real_data, + target_var, sorted_colors) + except KeyError: + data = [] + + modified_agp_obj = { + DATA: data, + CAT_EXP_BOX: [facet1, facet2], + FMT: gen_ticks_format(data) + } + col_info = copy.deepcopy(general_col_info) + col_info.update(modified_agp_obj) + plot_data.append(col_info) + + return plot_data + + +def get_div_col_name(graph_param: DicParam) -> str: + """Get division column name + Args: + graph_param: + Returns: + Column name which we will use to groupby final result. + """ + div_col_name = DIVIDE_FMT_COL + if graph_param.common.divide_format is None: + div_col_name = CfgProcessColumn.gen_label_from_col_id(graph_param.common.div_by_cat) + return div_col_name + + +def get_color_col_name(graph_param: DicParam, target_var: int) -> Optional[str]: + """Get color column name used to groupby from target variable + """ + color_id = graph_param.get_color_id(target_var) + color_col_name = CfgProcessColumn.gen_label_from_col_id(color_id) + return color_col_name + + +@log_execution_time() +def need_add_other_col(df: pd.DataFrame, graph_param: DicParam, target_var: int, maximum_allowed_groups: int) -> bool: + color_col_name = get_color_col_name(graph_param, target_var) + if color_col_name is None: + return False + return df[color_col_name].nunique(dropna=True) > maximum_allowed_groups + + +# TODO: write test for this +@log_execution_time() +def summarize_redundant_groups_into_others(df: pd.DataFrame, graph_param: DicParam, target_var: int, + maximum_allowed_groups: int, other_key: str, other_col: Optional[str]) -> \ + Tuple[pd.DataFrame, List[Any]]: + """Summarize color value if total unique value of color columns exceed `maximum_allowed_groups` + """ + color_col_name = get_color_col_name(graph_param, target_var) + if color_col_name is None or other_col is None: + return df, [] + df, sorted_colors = replace_redundant_groups_into_others(df, color_col_name, other_col, maximum_allowed_groups, + other_key) + return df, sorted_colors + + +@log_execution_time() +def replace_redundant_groups_into_others(df: pd.DataFrame, column_name: str, new_column_name: str, + maximum_allowed_groups: int, other_key: str) -> Tuple[pd.DataFrame, List[Any]]: + """ Rename redundant value in `column_name` into `other_key` + - We first sort value priority based on `value_counts`. + - Every key have smaller `value_counts` should have lower priority + """ + assert maximum_allowed_groups > 0 + + color_counts = df[column_name].value_counts() + counted_keys = color_counts.index.to_list() + + if maximum_allowed_groups >= len(counted_keys): + counted_keys.reverse() + return df, counted_keys + + non_redundant_groups = maximum_allowed_groups - 1 + df[new_column_name] = df[column_name].replace(to_replace=counted_keys[non_redundant_groups:], value=other_key) + + # form a new_keys, with OTHER_KEY always on tops + sorted_keys = [other_key] + counted_keys[:non_redundant_groups] + sorted_keys.reverse() + + return df, sorted_keys + + +@log_execution_time() +def get_data_for_target_var_without_facets(df: pd.DataFrame, graph_param: DicParam, is_real_data: bool, + target_var: int, sorted_colors: List[Any]) -> List[Dict[Any, Any]]: + chart_type = 'lines+markers' if is_real_data else 'bar' + + target_col = CfgProcessColumn.get_by_id(target_var) + target_col_name = CfgProcessColumn.gen_label_from_col_id(target_var) + + color_id = graph_param.get_color_id(target_var) + + def gen_trace_data(sub_df: pd.DataFrame, col_name: str) -> Dict[str, Any]: + trace_data = { + 'x': sub_df[target_col_name].index.tolist(), + 'y': list(sub_df[target_col_name].values), + COL_DETAIL_NAME: col_name, + COL_TYPE: chart_type, + } + if is_real_data: + # TODO: set `mode` in constant + trace_data['mode'] = chart_type + return trace_data + + if color_id is None: + plot_data = [gen_trace_data(df, target_col.name)] + else: + plot_data = [] + for color in sorted_colors: + try: + plot_data.append(gen_trace_data(df.xs(color), color)) + except KeyError: + pass + return plot_data + + +@log_execution_time() +def get_agg_cols(df: pd.DataFrame, graph_param: DicParam, target_var: int) -> List[str]: + agg_cols = [] + + facet_cols_name = [CfgProcessColumn.gen_label_from_col_id(col) for col in graph_param.common.cat_exp] + facet_cols_name = filter(lambda x: x is not None, facet_cols_name) + agg_cols.extend(facet_cols_name) + + color_col_name = get_color_col_name(graph_param, target_var) + if color_col_name is not None: + if need_add_other_col(df, graph_param, target_var, MAX_ALLOW_GROUPS): + agg_cols.append(OTHER_COL) + else: + agg_cols.append(color_col_name) + + div_col_name = get_div_col_name(graph_param) + agg_cols.append(div_col_name) + + return agg_cols + + +@log_execution_time() +def gen_groupby_from_target_var(df: pd.DataFrame, graph_param: DicParam, + target_var: int, is_real_data: bool) -> DataFrameGroupBy: + agg_cols = get_agg_cols(df, graph_param, target_var) + target_col_name = CfgProcessColumn.gen_label_from_col_id(target_var) + # remove na before apply aggregate method for real variable only + if is_real_data: + df_groupby = df.dropna(subset=[target_col_name]).groupby(agg_cols)[[target_col_name]] + else: + # count, do not remove na + df_groupby = df.groupby(agg_cols)[[target_col_name]] + return df_groupby + + +@log_execution_time() +def convert_utc_to_local_time_and_offset(df, graph_param): + client_timezone = graph_param.get_client_timezone() + # divide_offset = graph_param.common.divide_offset or 0 + client_tz = tz.gettz(client_timezone or None) or tz.tzlocal() + offset = get_utc_offset(client_tz) + df[Cycle.time.key] = pd.to_datetime(df[Cycle.time.key]) + offset + + return df + + +@log_execution_time() +def get_agg_lamda_func(df: DataFrameGroupBy, target_var, agg_func_name) -> pd.DataFrame: + if agg_func_name == HMFunction.std.name: + agg_params = {target_var: lambda x: agg_func_with_na(x, agg_func_name)} + agg_df = df.agg(agg_params) + elif agg_func_name == HMFunction.iqr.name: + # iqr + agg_df = df.agg(func=iqr, nan_policy='omit') + elif agg_func_name == HMFunction.range.name: + agg_params = {target_var: range_func} + agg_df = df.agg(agg_params) + else: + # median, mean, min, max + agg_df = df.agg(func=agg_func_name, numeric_only=False) + return agg_df + + +@log_execution_time() +def gen_ticks_format(data): + if not len(data): + return None + + # get fmt from first list of data instead of all data + ticks_format = get_fmt_from_array(data[0]['y']) + return ticks_format diff --git a/ap/api/analyze/services/pca.py b/ap/api/analyze/services/pca.py index e9d28a0..7c20c85 100644 --- a/ap/api/analyze/services/pca.py +++ b/ap/api/analyze/services/pca.py @@ -367,12 +367,12 @@ def gen_sensor_headers(orig_graph_param): short_names = {} used_names = set() for proc in orig_graph_param.array_formval: - for col_id, col_name in zip(proc.col_ids, proc.col_names): + for col_id, col_name, col_show_name in zip(proc.col_ids, proc.col_names, proc.col_show_names): name = gen_sql_label(col_id, col_name) - dic_labels[name] = col_name + dic_labels[name] = col_show_name # gen short name - new_name = gen_abbr_name(col_name) + new_name = gen_abbr_name(col_show_name) i = 1 while new_name in used_names: new_name = f'{new_name[0:-3]}({i})' diff --git a/ap/api/heatmap/services.py b/ap/api/heatmap/services.py index 79f1740..3d96feb 100644 --- a/ap/api/heatmap/services.py +++ b/ap/api/heatmap/services.py @@ -27,9 +27,6 @@ from ap.common.trace_data_log import TraceErrKey, EventType, EventAction, Target, trace_log from ap.setting_module.models import CfgProcess from ap.trace_data.schemas import DicParam -from ap.common.common_utils import DATE_FORMAT_STR, DATE_FORMAT_STR_CSV - -CHM_AGG_FUNC = [HMFunction.median.name, HMFunction.mean.name, HMFunction.std.name] @log_execution_time() @@ -206,7 +203,7 @@ def build_plot_data(df, end_col, hm_function): } -def get_function_i18n(hm_function): # TODO better. can be moved to frontend +def get_function_i18n(hm_function): """ Generate i18n aggregate function name """ return _('CHM' + hm_function.replace('_', ' ').title().replace(' ', '')) @@ -551,7 +548,7 @@ def agg_func_with_na(group_data, func_name): agg_func = { HMFunction.median.name: group_data.median, HMFunction.mean.name: group_data.mean, - HMFunction.std.name: group_data.mean, + HMFunction.std.name: group_data.std, } return agg_func[func_name](skipna=True) @@ -589,12 +586,8 @@ def groupby_and_aggregate(df: pd.DataFrame, hm_function: HMFunction, hm_mode, hm step_time = (hm_step * 60) if hm_mode == 1 else (hm_step * 3600) df[end_col] = step_time / df[end_col] else: - if hm_function.name not in CHM_AGG_FUNC: - agg_func = hm_function.name - else: - agg_func = lambda x: agg_func_with_na(x, hm_function.name) - agg_params = {end_col: agg_func, TIME_COL: HMFunction.first.name} - df = df.groupby(agg_cols).agg(agg_params).reset_index() + agg_params = {end_col: hm_function.name, TIME_COL: HMFunction.first.name} + df = df.groupby(agg_cols).agg(agg_params, numeric_only=False).reset_index() return df @@ -682,7 +675,8 @@ def gen_heatmap_data_as_dict(graph_param, dic_param, dic_proc_cfgs, dic_cat_filt var_agg_cols = [gen_sql_label(cfg_col.id, cfg_col.column_name) for cfg_col in cfg_facet_cols] # get sensor data from db - df, actual_record_number, unique_serial = get_data_from_db(graph_param, dic_cat_filters, use_expired_cache=use_expired_cache) + df, actual_record_number, unique_serial = get_data_from_db(graph_param, dic_cat_filters, + use_expired_cache=use_expired_cache) # filter by cat df, dic_param = filter_cat_dict_common(df, dic_param, dic_cat_filters, cat_exp, cat_procs, graph_param, True) @@ -721,6 +715,7 @@ def gen_heatmap_data_as_dict(graph_param, dic_param, dic_proc_cfgs, dic_cat_filt return dic_df_proc, hm_mode, hm_step, dic_col_func, df_cells, var_agg_cols, target_var_data, export_df + @log_execution_time() def get_target_variable_data_from_df(df, dic_proc_cfgs, graph_param, cat_only=True): target_data = [] @@ -815,4 +810,4 @@ def gen_sub_df_from_heatmap(heatmap_data, dic_params, dic_proc_cfgs, dic_col_fun def to_multiple_csv(data, delimiter=',', client_timezone=None): csv_data = [] for df in data: - csv_data.append(df.to_csv(sep=delimiter)) \ No newline at end of file + csv_data.append(df.to_csv(sep=delimiter)) diff --git a/ap/api/sankey_plot/sankey_glasso/sankey_services.py b/ap/api/sankey_plot/sankey_glasso/sankey_services.py index 7ad42ee..127bec0 100644 --- a/ap/api/sankey_plot/sankey_glasso/sankey_services.py +++ b/ap/api/sankey_plot/sankey_glasso/sankey_services.py @@ -164,7 +164,8 @@ def gen_graph_sankey_group_lasso(dic_param): x_cols = {key: val for key, val in dic_id_name.items() if key != y_id} groups = [dic_proc_cfgs.get(proc_id).name for key, proc_id in dic_col_proc_id.items() if key != y_id] - is_classif, dic_skd, dic_bar, dic_scp, dic_tbl, idx = gen_sankey_grouplasso_plot_data(df_sensors, x_cols, y_col, groups) + is_classif, dic_skd, dic_bar, dic_scp, dic_tbl, idx = gen_sankey_grouplasso_plot_data(df_sensors, x_cols, + y_col, groups) # get dic_scp dic_scp = dict(**dic_scp, **dic_tbl) @@ -242,10 +243,11 @@ def gen_sankey_grouplasso_plot_data(df: pd.DataFrame, x_cols, y_col, groups): y_1d = df[[y_col_id]].values # please set verbose=False if info should not be printed - is_classif, dic_skd, dic_bar, dic_scp, dic_tbl, idx = preprocess_skdpage(x_2d, y_1d, groups, x_col_names, y_col_name, - penalty_factors=[0.0, 0.1, 0.3, 1.0], - max_datapoints=10000, - verbose=True) + is_classif, dic_skd, dic_bar, dic_scp, dic_tbl, idx = preprocess_skdpage(x_2d, y_1d, groups, x_col_names, + y_col_name, + penalty_factors=[0.0, 0.1, 0.3, 1.0], + max_datapoints=10000, + verbose=True) return is_classif, dic_skd, dic_bar, dic_scp, dic_tbl, idx @@ -326,10 +328,10 @@ def get_sensors_objective_explanation(orig_graph_param): dic_id_name = {} dic_col_proc_id = {} for proc in orig_graph_param.array_formval: - for col_id, col_name in zip(proc.col_ids, proc.col_names): + for col_id, col_name, col_show_name in zip(proc.col_ids, proc.col_names, proc.col_show_names): label = gen_sql_label(col_id, col_name) dic_label_id[label] = col_id - dic_id_name[col_id] = col_name + dic_id_name[col_id] = col_show_name dic_col_proc_id[col_id] = proc.proc_id return dic_label_id, dic_id_name, dic_col_proc_id diff --git a/ap/api/scatter_plot/services.py b/ap/api/scatter_plot/services.py index d81c6ee..1479de3 100644 --- a/ap/api/scatter_plot/services.py +++ b/ap/api/scatter_plot/services.py @@ -28,6 +28,7 @@ Y_THRESHOLD, SCALE_SETTING, CHART_INFOS, X_SERIAL, Y_SERIAL, ARRAY_PLOTDATA, IS_DATA_LIMITED, ColorOrder, \ TIME_NUMBERINGS, SORT_KEY, VAR_TRACE_TIME, IS_RESAMPLING, CYCLE_IDS, SERIALS, DATETIME, START_PROC from ap.common.memoize import memoize +from ap.common.services.ana_inf_data import resample_preserve_min_med_max from ap.common.services.form_env import bind_dic_param_to_class from ap.common.services.request_time_out_handler import abort_process_handler, request_timeout_handling from ap.common.services.sse import notify_progress @@ -1172,39 +1173,3 @@ def reduce_data_by_number(df, max_graph, recent_flg=None): return df -@log_execution_time() -@abort_process_handler() -def resample_preserve_min_med_max(x, n_after: int): - """ Resample x, but preserve (minimum, median, and maximum) values - Inputs: - x (1D-NumpyArray or a list) - n_after (int) Length of x after resampling. Must be < len(x) - Return: - x (1D-NumpyArray) Resampled data - """ - if x.shape[0] > n_after: - # walkaround: n_after with odd number is easier - if n_after % 2 == 0: - n_after += 1 - - n = len(x) - n_half = int((n_after - 1) / 2) - - # index around median - x = np.sort(x) - idx_med = (n + 1) / 2 - 1 # median - idx_med_l = int(np.ceil(idx_med - 1)) # left of median - idx_med_r = int(np.floor(idx_med + 1)) # right of median - - # resampled index - idx_low = np.linspace(0, idx_med_l - 1, num=n_half, dtype=int) - idx_upp = np.linspace(idx_med_r, n - 1, num=n_half, dtype=int) - - # resampling - if n % 2 == 1: - med = x[int(idx_med)] - x = np.concatenate((x[idx_low], [med], x[idx_upp])) - else: - med = 0.5 * (x[idx_med_l] + x[idx_med_r]) - x = np.concatenate((x[idx_low], [med], x[idx_upp])) - return x diff --git a/ap/api/setting_module/services/csv_import.py b/ap/api/setting_module/services/csv_import.py index 94b79ba..e290ee1 100644 --- a/ap/api/setting_module/services/csv_import.py +++ b/ap/api/setting_module/services/csv_import.py @@ -43,8 +43,10 @@ def import_csv_job(_job_id, _job_name, _db_id, _proc_id, _proc_name, is_user_req _job_id {[type]} -- [description] (default: {None}) _job_name {[type]} -- [description] (default: {None}) """ + def _add_gen_proc_link_job(*_args, **_kwargs): add_gen_proc_link_job(is_user_request=is_user_request, *_args, **_kwargs) + kwargs.pop('is_user_request', None) gen = import_csv(*args, **kwargs) send_processing_info(gen, JobType.CSV_IMPORT, db_code=_db_id, process_id=_proc_id, process_name=_proc_name, @@ -201,7 +203,7 @@ def import_csv(proc_id, record_per_commit=RECORD_PER_COMMIT, is_user_request=Non continue default_csv_param['usecols'] = [i for i, col in enumerate(csv_cols) if col] - use_col_names = csv_cols + use_col_names = [col for col in csv_cols if col] # read csv file default_csv_param['dtype'] = {col: 'string' for col, data_type in dic_use_cols.items() if diff --git a/ap/api/setting_module/services/show_latest_record.py b/ap/api/setting_module/services/show_latest_record.py index b5dc893..b5bb9fb 100644 --- a/ap/api/setting_module/services/show_latest_record.py +++ b/ap/api/setting_module/services/show_latest_record.py @@ -1,6 +1,9 @@ import os from functools import lru_cache from itertools import islice +from typing import List, Tuple, Any + +import pandas as pd from ap.api.efa.services.etl import preview_data, detect_file_delimiter from ap.api.setting_module.services.csv_import import convert_csv_timezone @@ -21,7 +24,6 @@ make_session, CfgProcess, crud_config from ap.setting_module.schemas import VisualizationSchema from ap.trace_data.models import Sensor, find_sensor_class -import pandas as pd def get_latest_records(data_source_id, table_name, limit): @@ -242,6 +244,11 @@ def preview_csv_data(folder_url, etl_func, csv_delimiter, limit, return_df=False header_names = normalize_list(header_names) df_data_details = normalize_big_rows(data_details, header_names) data_types = [gen_data_types(df_data_details[col]) for col in header_names] + df_data_details, org_headers, header_names, dupl_cols, data_types = drop_null_header_column(df_data_details, + org_headers, + header_names, + dupl_cols, + data_types) else: # try to get file which has data to detect data types + get col names dic_file_info, csv_file = get_etl_good_file(sorted_files) @@ -282,7 +289,11 @@ def preview_csv_data(folder_url, etl_func, csv_delimiter, limit, return_df=False org_headers, header_names, dupl_cols = gen_colsname_for_duplicated(header_names) header_names = normalize_list(header_names) df_data_details = normalize_big_rows(data_details, header_names) - + df_data_details, org_headers, header_names, dupl_cols, data_types = drop_null_header_column(df_data_details, + org_headers, + header_names, + dupl_cols, + data_types) has_ct_col = True dummy_datetime_idx = None if df_data_details is not None: @@ -294,6 +305,8 @@ def preview_csv_data(folder_url, etl_func, csv_delimiter, limit, return_df=False validate_datetime(df_data_details, col, False, False) convert_csv_timezone(df_data_details, col) df_data_details.dropna(subset=[col], inplace=True) + # TODO: can we do this faster? + data_types = [gen_data_types(df_data_details[col]) for col in header_names] df_data_details = df_data_details[0:limit] if DataType.DATETIME.value not in data_types and DATETIME_DUMMY not in df_data_details.columns: @@ -381,7 +394,7 @@ def get_etl_good_file(sorted_files): dic_file_info, is_empty_file = check_result - if dic_file_info is None: + if dic_file_info is None or isinstance(dic_file_info, Exception): continue if is_empty_file: @@ -465,4 +478,25 @@ def gen_colsname_for_duplicated(cols_name): def is_valid_list(df_rows): return (isinstance(df_rows, list) and len(df_rows)) \ - or (isinstance(df_rows, pd.DataFrame) and not df_rows.empty) \ No newline at end of file + or (isinstance(df_rows, pd.DataFrame) and not df_rows.empty) + + +# TODO: add test for this +def drop_null_header_column(df: pd.DataFrame, original_headers: List[str], header_names: List[str], + duplicated_names: List[str], + data_types: List[int]) -> Tuple[pd.DataFrame, List[str], List[str], List[str], List[int]]: + null_indexes = {i for i, col_name in enumerate(original_headers) if not col_name} + if not null_indexes: + return df, original_headers, header_names, duplicated_names, data_types + + null_header_names = [col_name for i, col_name in enumerate(header_names) if i in null_indexes] + df = df.drop(columns=null_header_names) + + def filter_non_null_indexes(arr: List[Any]) -> List[Any]: + return [elem for i, elem in enumerate(arr) if i not in null_indexes] + + new_original_headers = filter_non_null_indexes(original_headers) + new_header_names = filter_non_null_indexes(header_names) + new_duplicated_names = filter_non_null_indexes(duplicated_names) + new_data_types = filter_non_null_indexes(data_types) + return df, new_original_headers, new_header_names, new_duplicated_names, new_data_types diff --git a/ap/api/trace_data/services/csv_export.py b/ap/api/trace_data/services/csv_export.py index 8a194c7..9ab061b 100644 --- a/ap/api/trace_data/services/csv_export.py +++ b/ap/api/trace_data/services/csv_export.py @@ -134,7 +134,10 @@ def to_csv(df: DataFrame, dic_proc_cfgs: Dict[int, CfgProcess], graph_param: Dic suffix = '...' dic_rename = {} for proc in graph_param.array_formval: - proc_cfg = dic_proc_cfgs[proc.proc_id] + proc_cfg = dic_proc_cfgs[proc.proc_id] if proc.proc_id in dic_proc_cfgs else None + if not proc_cfg: + continue + for col_id, col_name, name in zip(proc.col_ids, proc.col_names, proc.col_show_names): old_name = gen_sql_label(col_id, col_name) if old_name not in df.columns: diff --git a/ap/api/trace_data/services/proc_link.py b/ap/api/trace_data/services/proc_link.py index 8e5001e..594391e 100644 --- a/ap/api/trace_data/services/proc_link.py +++ b/ap/api/trace_data/services/proc_link.py @@ -4,14 +4,13 @@ from apscheduler.triggers import date, interval from apscheduler.triggers.date import DateTrigger -from loguru import logger from pytz import utc from sqlalchemy import insert from sqlalchemy.sql.expression import literal from ap import scheduler from ap.common.constants import * -from ap.common.logger import log_execution_time +from ap.common.logger import logger, log_execution_time from ap.common.memoize import set_all_cache_expired from ap.common.pydn.dblib.db_common import gen_insert_col_str, gen_select_col_str, PARAM_SYMBOL, add_single_quote from ap.common.pydn.dblib.db_proxy import DbProxy, gen_data_source_of_universal_db diff --git a/ap/api/trace_data/services/time_series_chart.py b/ap/api/trace_data/services/time_series_chart.py index ae73bfc..54ffff3 100644 --- a/ap/api/trace_data/services/time_series_chart.py +++ b/ap/api/trace_data/services/time_series_chart.py @@ -321,12 +321,21 @@ def gen_cat_label_unique(df, dic_param, graph_param, has_na=False, label_col_id= cat_exp_list = gen_unique_data(df, dic_proc_cfgs, cat_and_div, has_na) dic_param[CAT_EXP_BOX] = list(cat_exp_list.values()) + color_vals = [] + if graph_param.common.color_var: + color_vals.append(graph_param.common.color_var) + if graph_param.common.agp_color_vars: + agp_color_vals = list(set(int(color) for color in graph_param.common.agp_color_vars.values())) + color_vals += agp_color_vals + exclude_col_id = cat_and_div if label_col_id: exclude_col_id += label_col_id + exclude_col_id += color_vals sensor_ids = [col for col in graph_param.common.sensor_cols if col not in exclude_col_id] dic_param[CAT_ON_DEMAND] = list(gen_unique_data(df, dic_proc_cfgs, sensor_ids, True).values()) + dic_param[UNIQUE_COLOR] = list(gen_unique_data(df, dic_proc_cfgs, color_vals, False).values()) dic_param = gen_group_filter_list(df, graph_param, dic_param) @@ -1300,10 +1309,10 @@ def gen_trace_procs_sql(path, graph: TraceGraph, start_tm, end_tm, short_procs, # calculate +-14 day for end processes e_start_tm = convert_time(start_tm, return_string=False) e_start_tm = add_days(e_start_tm, -14) - e_start_tm = convert_time(e_start_tm) + e_start_tm = convert_time(e_start_tm, remove_ms=True) e_end_tm = convert_time(end_tm, return_string=False) e_end_tm = add_days(e_end_tm, 14) - e_end_tm = convert_time(e_end_tm) + e_end_tm = convert_time(e_end_tm, remove_ms=True) for from_proc, to_proc in zip(path[:-1], path[1:]): is_trace_forward = True edge_id = (from_proc, to_proc) diff --git a/ap/common/assets/assets.json b/ap/common/assets/assets.json new file mode 100644 index 0000000..7822366 --- /dev/null +++ b/ap/common/assets/assets.json @@ -0,0 +1,216 @@ +{ + "all": { + "js": [ + "common/js/libs/jquery.js", + "common/js/libs/jquery-ui.min.js", + "common/js/libs/datepicker.js", + "modules/popper/umd/popper.min.js", + "common/js/libs/bootstrap.min.js", + "common/js/libs/all.min.js", + "common/js/libs/jquery.ui.datepicker-ja.min.js", + "common/js/libs/moment-with-locales.js", + "common/js/libs/loadingoverlay.min.js", + "common/js/libs/lodash.min.js", + "common/js/libs/gtag.js", + "common/js/libs/clipboard.min.js", + "common/js/config_data_interface.js", + "common/js/libs/bootstrap-table.min.js", + "common/js/libs/bootstrap-table-filter-control.min.js", + "common/js/libs/bootstrap-table-locale-all.min.js", + "common/js/libs/html2canvas.min.js", + "common/js/take_screenshot.js", + "common/js/libs/plotly.min.js", + "common/js/libs/toastr.min.js", + "modules/js-datatables/lib/jquery.dataTables.min.js", + "common/js/libs/dataTables.fixedHeader.min.js", + "common/js/libs/select2.min.js", + "common/js/terms_of_use.js", + "common/js/divide_by_calendar.js", + "common/js/base.js", + "common/js/utils.js", + "common/js/summary_table.js", + "common/js/auto-update-common.js", + "common/js/data-finder.js", + "common/js/components.js", + "common/js/data_point_info_table.js", + "common/js/save_load_user_input.js", + "common/js/validation.js", + "common/js/clipboard_utils.js", + "common/js/libs/dragndrop.js", + "common/js/libs/d3-format.js", + "common/js/dn-custom-select.js", + "common/js/libs/popper.min.js", + "common/js/libs/shepherd.min.js", + "common/js/ap_tour.js", + "common/js/graph_nav.js", + "modules/jquery-ui-timepicker-addon/jquery-ui-timepicker-addon.min.js", + "modules/jquery-ui-timepicker-addon/i18n/jquery-ui-timepicker-ja.js", + "modules/date-range-picker/daterangepicker.js", + "modules/date-range-picker/daterangepicker-utils.js", + "common/js/libs/js.cookie.min.js", + "common/js/libs/pagination.min.js" + ], + "css": [ + "common/custom-jquery/jquery-ui.css", + "common/css/select2.min.css", + "common/css/bootstrap.min.css", + "common/css/all.min.css", + "common/css/main.css", + "common/css/components.css", + "common/css/dragndrop.css", + "common/css/bootstrap-table.min.css", + "common/css/toastr.css", + "common/css/data-finder.css", + "modules/js-datatables/lib/jquery.dataTables.min.css", + "common/css/user-setting-table.css", + "common/css/shepherd.css", + "modules/jquery-ui-timepicker-addon/jquery-ui-timepicker-addon.css", + "modules/date-range-picker/daterangepicker.css", + "common/css/pagination.css", + "common/css/graph_nav.css" + ] + }, + "fpp": { + "js": [ + "common/js/libs/Chart.min.js", + "common/js/libs/chartjs-adapter-moment.min.js", + "common/js/libs/chartjs-plugin-annotation-latest.min.js", + "trace_data/js/trace_data_time_series.js", + "trace_data/js/trace_data_histogram.js", + "trace_data/js/trace_data_step_bar_chart.js", + "trace_data/js/trace_data_summary_table.js", + "trace_data/js/trace_data_cross_hair.js", + "trace_data/js/trace_data_categorical_table.js", + "trace_data/js/trace_data_scatter_plot.js", + "trace_data/js/trace_data_whisker_plot.js", + "common/js/cat_facet_label_filter_modal.js", + "trace_data/js/trace_data.js", + "trace_data/js/trace_data_histogram_with_kde.js" + ], + "css": [ + "trace_data/css/trace_data.css" + ] + }, + "stp": { + "js": [ + "categorical_plot/js/categorical_histogram_with_density_curve.js", + "categorical_plot/js/categorical_plot_utils.js", + "categorical_plot/js/categorical_plot.js", + "common/js/cat_facet_label_filter_modal.js" + ], + "css": [ + "categorical_plot/css/categorical_plot.css" + ] + }, + "rlp": { + "js": [ + "ridgeline_plot/js/rlp_template.js", + "ridgeline_plot/js/ridgeline_plot_utils.js", + "ridgeline_plot/js/ridgeline_plot.js", + "common/js/cat_facet_label_filter_modal.js" + ], + "css": [ + "ridgeline_plot/css/ridgeline.css" + ] + }, + "chm": { + "js": [ + "heatmap/js/heatmap.js", + "heatmap/js/heatmap_plotly.js", + "common/js/cat_facet_label_filter_modal.js" + ], + "css": [ + "heatmap/css/heat_map.css" + ] + }, + "msp": { + "js": [ + "multiple_scatter_plot/js/multiple_scatter_histogram.js", + "multiple_scatter_plot/js/multiple_scatter_contour.js", + "multiple_scatter_plot/js/multiple_scatter_plot.js" + ], + "css": [ + "multiple_scatter_plot/css/multiple_scatter_plot.css" + ] + }, + "scp": { + "js": [ + "common/js/libs/Chart.bundle.min.js", + "scatter_plot/js/scatter_chart.js", + "scatter_plot/js/hist_with_density.js", + "scatter_plot/js/scatter_plot.js", + "scatter_plot/js/scp_heatmap_plot.js", + "scatter_plot/js/scp_violin_plot.js", + "common/js/cat_facet_label_filter_modal.js" + ], + "css": [ + "scatter_plot/css/scatter_plot.css" + ] + }, + "pcp": { + "js": [ + "common/js/libs/Chart.bundle.min.js", + "common/js/libs/fSelect.js", + "parallel_plot/js/parallel_plot.js", + "common/js/cat_facet_label_filter_modal.js" + ], + "css": [ + "common/css/fSelect.css", + "parallel_plot/css/parallel_plot.css" + ] + }, + "skd": { + "js": [ + "sankey_plot/js/sankey_plot.js", + "sankey_plot/js/sankey_scp.js" + ], + "css": [ + "sankey_plot/css/sankey_plot.css" + ] + }, + "cog": { + "js": [ + "modules/sigmajs/build/sigma.min.js", + "modules/sigmajs/build/plugins/sigma.renderers.edgeLabels.min.js", + "modules/sigmajs/build/plugins/sigma.plugins.dragNodes.min.js", + "modules/sigmajs/build/plugins/sigma.layout.forceAtlas2.min.js", + "modules/sigmajs/build/plugins/sigma.plugins.animate.min.js", + "modules/sigmajs/build/plugins/sigma.layout.noverlap.min.js", + "co_occurrence/js/pareto_plot.js", + "co_occurrence/js/co_occurrence_csv.js" + ], + "css": [ + "co_occurrence/css/co_occurrence_csv.css" + ] + }, + "pca": { + "js": [ + "common/js/libs/jquery.dataTables.min.js", + "common/js/libs/dataTables.bootstrap4.min.js", + "common/js/libs/dom-text.js", + "analyze/js/pca_toastr.js", + "analyze/js/generateJson.js", + "analyze/js/hotelling_common.js", + "analyze/js/hotelling_timeseries.js", + "analyze/js/hotelling_scatters.js", + "analyze/js/hotelling_biplot.js", + "analyze/js/hotelling_q_contribution.js", + "analyze/js/hotelling_t2_contribution.js", + "analyze/js/pca.js" + ], + "css": [ + "modules/js-datatables/lib/jquery.dataTables.min.css", + "analyze/css/anomaly_detection.css" + ] + }, + "agp": { + "js": [ + "aggregate_plot/js/aggregation_chart.js", + "aggregate_plot/js/aggregate_plot.js", + "common/js/cat_facet_label_filter_modal.js" + ], + "css": [ + "aggregate_plot/css/aggregate_plot.css" + ] + } +} \ No newline at end of file diff --git a/ap/common/common_utils.py b/ap/common/common_utils.py index 153a063..fb17b60 100644 --- a/ap/common/common_utils.py +++ b/ap/common/common_utils.py @@ -1,12 +1,12 @@ import copy import csv import fnmatch +import json import locale import os import pickle import re import shutil -import socket import sys from collections import OrderedDict from datetime import datetime, timedelta @@ -20,11 +20,12 @@ from dateutil import parser from dateutil.relativedelta import relativedelta from flask import g +from flask_assets import Environment, Bundle from pandas import DataFrame from ap.common.constants import AbsPath, DataType, YAML_AUTO_INCREMENT_COL, CsvDelimiter, R_PORTABLE, \ SQL_COL_PREFIX, FilterFunc, ENCODING_ASCII, ENCODING_UTF_8, FlaskGKey, LANGUAGES, ENCODING_SHIFT_JIS, \ - ENCODING_UTF_8_BOM + ENCODING_UTF_8_BOM, appENV from ap.common.logger import logger, log_execution_time from ap.common.services.normalization import unicode_normalize_nfkc @@ -281,7 +282,7 @@ def universal_db_exists(): # convert time before save to database YYYY-mm-DDTHH:MM:SS.NNNNNNZ -def convert_time(time=None, format_str=DATE_FORMAT_STR, return_string=True, only_milisecond=False): +def convert_time(time=None, format_str=DATE_FORMAT_STR, return_string=True, only_milisecond=False, remove_ms=False): if not time: time = datetime.utcnow() elif isinstance(time, str): @@ -291,6 +292,8 @@ def convert_time(time=None, format_str=DATE_FORMAT_STR, return_string=True, only time = time.strftime(format_str) if only_milisecond: time = time[:-3] + elif remove_ms: + time = time[:-8] return time @@ -1109,3 +1112,37 @@ class NoDataFoundException(Exception): def __init__(self): super().__init__() self.code = 999 + +def bundle_assets(_app): + """ + bundle assets when application be started at the first time + for commnon assets (all page), and single page + """ + env = os.environ.get('ANALYSIS_INTERFACE_ENV') + # bundle js files + assets_path = os.path.join('ap', 'common', 'assets', 'assets.json') + with open(assets_path, 'r') as f: + _assets = json.load(f) + + assets = Environment(_app) + if env != appENV.PRODUCTION.value: + assets.debug = True + + for page in _assets.keys(): + js_assets = _assets[page].get('js') or [] + css_assets = _assets[page].get('css') or [] + js_asset_name = f'js_{page}' + css_asset_name = f'css_{page}' + if env != appENV.PRODUCTION.value: + assets.register(js_asset_name, *js_assets) + assets.register(css_asset_name, *css_assets) + else: + js_bundle = Bundle(*js_assets, + output=f'common/js/{page}.packed.js') + css_bundle = Bundle(*css_assets, + output=f'common/css/{page}.packed.css') + assets.register(js_asset_name, js_bundle) + assets.register(css_asset_name, css_bundle) + # build assets + js_bundle.build() + css_bundle.build() \ No newline at end of file diff --git a/ap/common/constants.py b/ap/common/constants.py index 336e51d..4d4de1a 100644 --- a/ap/common/constants.py +++ b/ap/common/constants.py @@ -41,8 +41,11 @@ DATETIME_DUMMY = 'DatetimeDummy' MAX_DATETIME_STEP_PER_DAY = 8640 # 10s/step -> 6steps * 60min * 24hrs +RESAMPLING_SIZE = 10_000 + LOG_LEVEL = 'log_level' + class AP_LOG_LEVEL(Enum): DEBUG = auto() INFO = auto() @@ -330,6 +333,17 @@ class ErrorMsg(Enum): SERIALS = 'serials' DATETIME = 'datetime' +AGP_COLOR_VARS = 'aggColorVar' +DIVIDE_OFFSET = 'divideOffset' +DIVIDE_FMT = 'divideFormat' +DIVIDE_FMT_COL = 'divide_format' +COLOR_NAME = 'color_name' +DATA = 'data' +SHOWN_NAME = 'shown_name' +COL_DATA_TYPE = 'data_type' +DIVIDE_CALENDAR_DATES = 'divDates' +DIVIDE_CALENDAR_LABELS = 'divFormats' + class HMFunction(Enum): max = auto() @@ -414,7 +428,8 @@ class MemoizeKey(Enum): # error message for dangling jobs FORCED_TO_BE_FAILED = 'DANGLING JOB. FORCED_TO_BE_FAILED' -DEFAULT_POLLING_FREQ = 180 # default is import every 3 minutes +DEFAULT_POLLING_FREQ = 180 # default is import every 3 minutes + class CfgConstantType(Enum): def __str__(self): diff --git a/ap/common/services/ana_inf_data.py b/ap/common/services/ana_inf_data.py index 138a1bf..406047b 100644 --- a/ap/common/services/ana_inf_data.py +++ b/ap/common/services/ana_inf_data.py @@ -7,6 +7,8 @@ from scipy.stats import gaussian_kde, iqr from ap.common.constants import * +from ap.common.logger import log_execution_time +from ap.common.services.request_time_out_handler import abort_process_handler from ap.common.sigificant_digit import get_fmt_from_array from ap.common.services.statistics import convert_series_to_number @@ -168,7 +170,7 @@ def calculate_kde_trace_data(plotdata, bins=128, height=1, full_array_y=None): data = convert_series_to_number(data) data = data[np.isfinite(data)] if full_array_y and len(data) > 10_000: - sample_data = np.random.choice(data, size=10_000).tolist() + sample_data = resample_preserve_min_med_max(data, RESAMPLING_SIZE - 1).tolist() else: sample_data = data.tolist() @@ -351,3 +353,40 @@ def detect_abnormal_count_values(x, nmax=2, threshold=50, min_uniques=10, max_sa print("outlier not detected") return val_outlier + +@log_execution_time() +@abort_process_handler() +def resample_preserve_min_med_max(x, n_after: int): + """ Resample x, but preserve (minimum, median, and maximum) values + Inputs: + x (1D-NumpyArray or a list) + n_after (int) Length of x after resampling. Must be < len(x) + Return: + x (1D-NumpyArray) Resampled data + """ + if x.shape[0] > n_after: + # walkaround: n_after with odd number is easier + if n_after % 2 == 0: + n_after += 1 + + n = len(x) + n_half = int((n_after - 1) / 2) + + # index around median + x = np.sort(x) + idx_med = (n + 1) / 2 - 1 # median + idx_med_l = int(np.ceil(idx_med - 1)) # left of median + idx_med_r = int(np.floor(idx_med + 1)) # right of median + + # resampled index + idx_low = np.linspace(0, idx_med_l - 1, num=n_half, dtype=int) + idx_upp = np.linspace(idx_med_r, n - 1, num=n_half, dtype=int) + + # resampling + if n % 2 == 1: + med = x[int(idx_med)] + x = np.concatenate((x[idx_low], [med], x[idx_upp])) + else: + med = 0.5 * (x[idx_med_l] + x[idx_med_r]) + x = np.concatenate((x[idx_low], [med], x[idx_upp])) + return x diff --git a/ap/common/services/csv_content.py b/ap/common/services/csv_content.py index 84036d0..482fb8f 100644 --- a/ap/common/services/csv_content.py +++ b/ap/common/services/csv_content.py @@ -1,8 +1,8 @@ # CSVコンテンツを生成するService # read(dic_form, local_params)が外向けの関数 -import io import csv import decimal +import io # https://stackoverrun.com/ja/q/6869533 import logging import math @@ -42,17 +42,15 @@ def read_data(f_name, headers=None, skip_head=None, end_row=None, delimiter=',', rows = csv.reader((line.replace('\0', '') for line in f), delimiter=delimiter) # skip tail - if end_row != None: + if end_row is not None: rows = islice(rows, end_row + 1) - # skip head - if skip_head != None: - for _ in range(skip_head): - next(rows) + if skip_head is not None: + rows = islice(rows, skip_head, None) # use specify header( may be get from yaml config) csv_headers = next(rows) - if headers: + if headers is not None: yield normalize_func(headers) else: yield normalize_func(csv_headers) @@ -252,4 +250,3 @@ def zip_file_to_response(csv_data, file_names): }) return response - diff --git a/ap/common/services/form_env.py b/ap/common/services/form_env.py index 31d7741..4b65996 100644 --- a/ap/common/services/form_env.py +++ b/ap/common/services/form_env.py @@ -6,11 +6,11 @@ from ap.common.common_utils import as_list from ap.common.constants import * +from ap.common.services.jp_to_romaji_utils import to_romaji from ap.setting_module.models import CfgProcessColumn from ap.setting_module.services.process_config import get_all_process_no_nested, get_all_visualizations from ap.trace_data.schemas import DicParam, CommonParam, ConditionProc, CategoryProc, EndProc, \ ConditionProcDetail -from ap.common.services.jp_to_romaji_utils import to_romaji logger = logging.getLogger(__name__) @@ -25,7 +25,9 @@ CYCLIC_DIV_NUM, CYCLIC_WINDOW_LEN, CYCLIC_INTERVAL, MATRIX_COL, COLOR_ORDER, IS_EXPORT_MODE, IS_IMPORT_MODE, VAR_TRACE_TIME, TERM_TRACE_TIME, CYCLIC_TRACE_TIME, TRACE_TIME, EMD_TYPE, ABNORMAL_COUNT, REMOVE_OUTLIER_OBJECTIVE_VAR, REMOVE_OUTLIER_EXPLANATORY_VAR, - DUPLICATE_SERIAL_SHOW, EXPORT_FROM, DUPLICATED_SERIALS_COUNT) + DUPLICATE_SERIAL_SHOW, EXPORT_FROM, DUPLICATED_SERIALS_COUNT, AGP_COLOR_VARS, DIVIDE_FMT, + DIVIDE_OFFSET, DIVIDE_CALENDAR_DATES, DIVIDE_CALENDAR_LABELS + ) conds_startwith_keys = ('filter-', 'cond_', 'machine_id_multi') @@ -119,8 +121,10 @@ def parse_multi_filter_into_one(dic_form): dic_cat_filters = json.loads(value) if dic_cat_filters: dic_parsed[COMMON][key] = {int(col): vals for col, vals in dic_cat_filters.items()} - elif key in (TEMP_CAT_EXP, TEMP_CAT_PROCS): + elif key in (TEMP_CAT_EXP, TEMP_CAT_PROCS, AGP_COLOR_VARS, DIVIDE_CALENDAR_DATES, DIVIDE_CALENDAR_LABELS): dic_parsed[COMMON][key] = json.loads(value) + elif key.startswith((VAR_TRACE_TIME, TRACE_TIME)): + dic_parsed[COMMON][TRACE_TIME] = value else: dic_parsed[COMMON][key] = value @@ -387,7 +391,14 @@ def bind_dic_param_to_class(dic_param): sensor_cols=sensor_cols, duplicate_serial_show=dic_common.get(DUPLICATE_SERIAL_SHOW, DuplicateSerialShow.SHOW_BOTH), is_export_mode=dic_common.get(IS_EXPORT_MODE, False), - duplicated_serials_count=dic_common.get(DUPLICATED_SERIALS_COUNT, DuplicateSerialCount.AUTO.value) + duplicated_serials_count=dic_common.get(DUPLICATED_SERIALS_COUNT, + DuplicateSerialCount.AUTO.value), + divide_format=dic_common.get(DIVIDE_FMT, None), + divide_offset=dic_common.get(DIVIDE_OFFSET, None), + agp_color_vars=dic_common.get(AGP_COLOR_VARS, None), + is_latest=True if dic_common.get(TRACE_TIME, 'default') == 'recent' else False, + divide_calendar_dates=dic_common.get(DIVIDE_CALENDAR_DATES, []), + divide_calendar_labels=dic_common.get(DIVIDE_CALENDAR_LABELS, []), ) # use the first end proc as start proc @@ -496,7 +507,7 @@ def get_end_procs_param(dic_param): def update_data_from_multiple_dic_params(orig_dic_param, dic_param): updated_keys = [ARRAY_PLOTDATA, ACT_CELLS, UNIQUE_SERIAL, MATCHED_FILTER_IDS, UNMATCHED_FILTER_IDS, \ NOT_EXACT_MATCH_FILTER_IDS, CAT_EXP_BOX, ACTUAL_RECORD_NUMBER, IMAGES, IS_GRAPH_LIMITED, \ - EMD_TYPE, TIME_CONDS, FMT, CAT_ON_DEMAND] + EMD_TYPE, TIME_CONDS, FMT, CAT_ON_DEMAND, UNIQUE_COLOR] for key in updated_keys: if key not in dic_param: continue diff --git a/ap/common/trace_data_log.py b/ap/common/trace_data_log.py index 829eca8..9af3154 100644 --- a/ap/common/trace_data_log.py +++ b/ap/common/trace_data_log.py @@ -68,6 +68,7 @@ class EventType(Enum): PCP = 'PCP' CHM = 'CHM' SCP = 'ScP' + AGP = 'AGP' class EventAction(Enum): diff --git a/ap/config/image/AgP.png b/ap/config/image/AgP.png new file mode 100644 index 0000000000000000000000000000000000000000..c4b9959a4c2cdb66037bdf20c854883b0ff8bd1a GIT binary patch literal 39352 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